Skip to content

fix(react-query): resolve hydration mismatch in SSR with prefetched queries #9572

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from

Conversation

joseph0926
Copy link
Contributor

@joseph0926 joseph0926 commented Aug 17, 2025

fixes #9399
fixes #4690
Comment: #9399 (comment)

Description

This PR fixes a hydration mismatch issue that occurs when using prefetchQuery in Next.js App Router with React Query v5.

The Problem

I briefly looked into the issue.
It's really interesting…

I added a few debugging logs to the reporter's code and checked the results.

"use client";

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getAmountOfUsers } from "./fetcher";
import { useEffect, useState } from "react";

const PageClient = ({}) => {
  console.log("=== PageClient Render START ===");

  const queryClient = useQueryClient();
  const cache = queryClient.getQueryCache().find({
    queryKey: ["users", "amount"],
  });

  console.log("!!! Cache:", {
    status: cache?.state.status,
    data: cache?.state.data,
    promise: cache?.promise,
    promiseType: typeof cache?.promise,
  });

  console.log("@@@ Before useQuery:", {
    cacheExists: !!cache,
    cacheState: cache?.state.status,
    cacheData: cache?.state.data,
  });

  const query = useQuery(getAmountOfUsers());

  console.log("### After useQuery:", {
    isLoading: query.isLoading,
    data: query.data,
    fetchStatus: query.fetchStatus,
  });

  console.log("=== PageClient Render END ===");

  return (
    <div>
      Amount of users:{" "}
      {query.isLoading ? <em>Loading...</em> : query.data?.amount}
    </div>
  );
};

export default PageClient;

I naturally expected that since the server prefetches and hydrates before sending to the client, if the server's data is undefined, the client would also have undefined, and if the server has data, the client would have it as well.

But the results were as follows

// client
=== PageClient Render START ===
page-client.tsx:15 !!! Cache: {status: 'success', data: {…}, promise: Promise, promiseType: 'object'}data: {amount: 12}promise: Promise {<fulfilled>: {…}, status: 'fulfilled', value: {…}}promiseType: "object"status: "success"[[Prototype]]: Object
page-client.tsx:22 @@@ Before useQuery: {cacheExists: true, cacheState: 'success', cacheData: {…}}cacheData: {amount: 12}cacheExists: truecacheState: "success"[[Prototype]]: Object
page-client.tsx:30 ### After useQuery: {isLoading: false, data: {…}, fetchStatus: 'fetching'}data: {amount: 12}fetchStatus: "fetching"isLoading: false[[Prototype]]: Object
page-client.tsx:36 === PageClient Render END ===

// server
!!! Cache: {
  status: 'pending',
  data: undefined,
  promise: Promise {}
  promiseType: 'object'
}

@@@ Before useQuery: { cacheExists: true, cacheState: 'pending', cacheData: undefined }
### After useQuery: { isLoading: true, data: undefined, fetchStatus: 'fetching' }
=== PageClient Render END ===

Sometimes it was undefined on the server, yet the client had data.

I thought about why this might be possible.

Possible hypothesis =>
// 1. RSC (React Server Component) stage

  • Render Layout
  • Run page.tsx (server component)
    getServerQueryClient() creates QueryClient A
    prefetchQuery() runs (queryFn execution "1")
    dehydrate(QueryClient A) = serialize data

// 2. SSR (Server Side Rendering) stage

  • ClientProvider runs
    createQueryClient() creates QueryClient B (new, empty cache!)
  • Render PageClient (uses QueryClient B)
    → Cache is empty → useQuery starts fetch (queryFn execution "2")
    status: 'pending', isLoading: true
    → HTML: <em>Loading...</em>

// 3. RSC payload generation

  • Next.js injects the result of queryFn 2 into the RSC payload
    → e.g., {"amount":14}
    This is what I actually observed (you can find this injection in the Network tab > search for amount under localhost)
<script>
  self.__next_f.push([1, "...59:{\"amount\":12}..."])
</script>

Then the client state becomes
// 1. Initial state

  • ClientProvider: creates QueryClient C (new)
  • HydrationBoundary: receives the dehydrated state from QueryClient A

// 2. During hydration

  • In HydrationBoundary's useMemo
    → Finds new queries → hydrates immediately
    → Creates a Promise-like object
    → Fills it with data from the RSC payload

// 3. React Query v5 tryResolveSync

  • Detects an already resolved Promise-like object
  • Extracts data synchronously!
  • status: 'success', data: { amount: 14 }

// 4. First render

  • isLoading: false
  • DOM shows "14"
  • React: HTML mismatch! (<em>Loading...</em> vs "14")

In summary, the core points seem to be

  1. Multiple QueryClient instances are created independently, fragmenting state across them.
  2. There's a collision (or unintended interaction) between the RSC payload and React Query's tryResolveSync behavior.

The Solution

To resolve this hydration mismatch, I implemented getServerSnapshot support in useSyncExternalStore

1. Added getServerResult() method to QueryObserver

This method detects hydrated data (where dataUpdatedAt === 0) and returns a pending state for server-side rendering

getServerResult(): QueryObserverResult<TData, TError> {
  // If data was hydrated (dataUpdatedAt === 0), 
  // return pending state for server to match initial HTML
  if (isHydratedData) {
    return {
      ...currentResult,
      status: 'pending',
      isPending: true,
      isLoading: true,
      data: undefined,
    }
  }
  return currentResult
}

2. Updated useBaseQuery to use proper server snapshot

React.useSyncExternalStore(
  subscribe,
  () => observer.getCurrentResult(),  // client snapshot
  () => observer.getServerResult(),   // server snapshot
)

Testing

I've added comprehensive tests to verify the fix

describe('SSR Hydration', () => {
  test('should demonstrate hydration mismatch issue (before fix)')
  test('getServerResult should return pending state for hydrated data')
  test('should handle fetching state during hydration')
  test('should return normal result for non-hydrated data')
  test('should handle error state correctly')
  test('should provide different snapshots for server and client')
})

All tests are passing

Build & Test Results

I've successfully run the following locally

  • pnpm test - All tests passing
  • pnpm build:all - Build successful
  • pnpm test:types - Type checks passing

Related Issues

Breaking Changes

None. This is a backward-compatible fix that only affects SSR behavior.

Summary by CodeRabbit

  • Bug Fixes

    • Improved SSR hydration handling: during hydration the app now uses server-view snapshots so hydrated queries show appropriate pending/loading/fetching/error states, reducing UI mismatches without changing public APIs.
  • Tests

    • Added extensive SSR hydration tests covering pending, fetching, success, error, concurrency, race conditions, and divergent server vs. client snapshots.

Copy link

coderabbitai bot commented Aug 17, 2025

Walkthrough

Adds a non-mutating server-view snapshot method to QueryObserver, switches useBaseQuery's useSyncExternalStore snapshot to that server view for SSR hydration, and adds extensive tests validating server vs. client snapshots across pending, fetching, error, and concurrency scenarios.

Changes

Cohort / File(s) Summary of Changes
SSR Hydration Tests
packages/query-core/src/__tests__/queryObserver.test.tsx
Added extensive hydration-focused tests that mutate QueryCache state (dataUpdatedAt, fetchStatus) and assert getCurrentResult and the new getServerResult behavior across pending, fetching, error, normal, concurrency, and useSyncExternalStore divergence scenarios.
Observer Server Snapshot
packages/query-core/src/queryObserver.ts
Added `getServerResult(): QueryObserverPendingResult<TData,TError>
React Hook Snapshot Source
packages/react-query/src/useBaseQuery.ts
Switched the snapshot getter passed to React.useSyncExternalStore from observer.getCurrentResult() to observer.getServerResult() so SSR hydration captures the frozen server snapshot.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant C as React Component
  participant H as useBaseQuery
  participant O as QueryObserver
  participant QC as QueryCache

  C->>H: render()
  H->>O: create/attach observer
  H->>H: useSyncExternalStore(subscribe, getServerSnapshot)
  H->>O: getServerResult()
  O->>QC: read query state (dataUpdatedAt, fetchStatus)
  alt dataUpdatedAt == 0 (hydrated)
    Note right of O: build synthetic server view\n(status: pending / isPending: true)
    O-->>H: serverResult (pending-like, data undefined or masked)
  else
    O-->>H: currentResult (unchanged)
  end
  H-->>C: snapshot for render
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Assessment against linked issues

Objective (issue) Addressed Explanation
Fix hydration error when prefetching / avoid client hydration loading mismatch (#9399)
Provide correct/frozen getServerSnapshot for useSyncExternalStore (#4690)

"I nibble bytes where queries dwell,
A carrot glows: 'Hydrate as well!'
Server whispers 'pending, hold the cheer,'
Client hums 'success — the data's here!'
Hop, sync, and cache, we both appear. 🥕✨"

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 25fc95e and 3429d75.

📒 Files selected for processing (1)
  • packages/query-core/src/queryObserver.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/query-core/src/queryObserver.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Preview
  • GitHub Check: Test
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

nx-cloud bot commented Aug 17, 2025

View your CI Pipeline Execution ↗ for commit 3429d75

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 2m 58s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 20s View ↗

☁️ Nx Cloud last updated this comment at 2025-08-20 05:55:25 UTC

Copy link

pkg-pr-new bot commented Aug 17, 2025

More templates

@tanstack/angular-query-devtools-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-devtools-experimental@9572

@tanstack/angular-query-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-experimental@9572

@tanstack/eslint-plugin-query

npm i https://pkg.pr.new/@tanstack/eslint-plugin-query@9572

@tanstack/query-async-storage-persister

npm i https://pkg.pr.new/@tanstack/query-async-storage-persister@9572

@tanstack/query-broadcast-client-experimental

npm i https://pkg.pr.new/@tanstack/query-broadcast-client-experimental@9572

@tanstack/query-core

npm i https://pkg.pr.new/@tanstack/query-core@9572

@tanstack/query-devtools

npm i https://pkg.pr.new/@tanstack/query-devtools@9572

@tanstack/query-persist-client-core

npm i https://pkg.pr.new/@tanstack/query-persist-client-core@9572

@tanstack/query-sync-storage-persister

npm i https://pkg.pr.new/@tanstack/query-sync-storage-persister@9572

@tanstack/react-query

npm i https://pkg.pr.new/@tanstack/react-query@9572

@tanstack/react-query-devtools

npm i https://pkg.pr.new/@tanstack/react-query-devtools@9572

@tanstack/react-query-next-experimental

npm i https://pkg.pr.new/@tanstack/react-query-next-experimental@9572

@tanstack/react-query-persist-client

npm i https://pkg.pr.new/@tanstack/react-query-persist-client@9572

@tanstack/solid-query

npm i https://pkg.pr.new/@tanstack/solid-query@9572

@tanstack/solid-query-devtools

npm i https://pkg.pr.new/@tanstack/solid-query-devtools@9572

@tanstack/solid-query-persist-client

npm i https://pkg.pr.new/@tanstack/solid-query-persist-client@9572

@tanstack/svelte-query

npm i https://pkg.pr.new/@tanstack/svelte-query@9572

@tanstack/svelte-query-devtools

npm i https://pkg.pr.new/@tanstack/svelte-query-devtools@9572

@tanstack/svelte-query-persist-client

npm i https://pkg.pr.new/@tanstack/svelte-query-persist-client@9572

@tanstack/vue-query

npm i https://pkg.pr.new/@tanstack/vue-query@9572

@tanstack/vue-query-devtools

npm i https://pkg.pr.new/@tanstack/vue-query-devtools@9572

commit: 3429d75

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (4)
packages/react-query/src/useBaseQuery.ts (1)

111-113: Optionally harden against version skew between @tanstack/react-query and @tanstack/query-core

If a consumer upgrades react-query without upgrading query-core, observer.getServerResult may not exist at runtime. A small runtime fallback guards against that without affecting current behavior.

Apply this diff:

     ),
     () => observer.getCurrentResult(),
-    () => observer.getServerResult(),
+    () =>
+      // Guard against version skew where older query-core doesn't have getServerResult
+      (observer as any).getServerResult
+        ? (observer as any).getServerResult()
+        : observer.getCurrentResult(),
   )
packages/query-core/src/queryObserver.ts (2)

274-287: Consider normalizing “pending” snapshot flags for full internal consistency

When returning a synthetic pending result, isFetched/isFetchedAfterMount could still reflect “fetched” from the spread. While likely harmless for SSR, explicitly setting them to false would avoid mixed semantics (“pending but fetched”).

Apply this diff:

       const pendingResult: QueryObserverResult<TData, TError> = {
         ...currentResult,
         status: 'pending',
         isPending: true,
         isSuccess: false,
         isError: false,
         isLoading: currentResult.fetchStatus === 'fetching',
         isInitialLoading: currentResult.fetchStatus === 'fetching',
         data: undefined,
         error: null,
         isLoadingError: false,
         isRefetchError: false,
         isPlaceholderData: false,
+        // Normalize fetched flags in the synthetic pending server snapshot
+        isFetched: false,
+        isFetchedAfterMount: false,
       } as QueryObserverResult<TData, TError>

267-269: Add a short JSDoc explaining server snapshot semantics

A brief doc comment will clarify why dataUpdatedAt === 0 is treated as hydrated and why we synthesize a pending result.

Apply this diff:

-  getServerResult(): QueryObserverResult<TData, TError> {
+  /**
+   * Returns a "server-view" snapshot for useSyncExternalStore during SSR.
+   * If the observer has hydrated client data (dataUpdatedAt === 0) but the server
+   * should render a pending UI, synthesize a pending result; otherwise, return the current result.
+   */
+  getServerResult(): QueryObserverResult<TData, TError> {
packages/query-core/src/__tests__/queryObserver.test.tsx (1)

1498-1523: Deduplicate “mark as hydrated” setup with a small helper

Multiple tests inline the same mutation of dataUpdatedAt and fetchStatus. A helper will reduce repetition and make intent clearer.

An example helper to insert right after the “SSR Hydration” describe:

 describe('SSR Hydration', () => {
+  const markHydrated = (
+    qc: QueryClient,
+    key: ReturnType<typeof queryKey>,
+    data: unknown,
+    fetchStatus: 'idle' | 'fetching' = 'idle',
+  ) => {
+    qc.setQueryData(key, data)
+    const cache = qc.getQueryCache().find({ queryKey: key })
+    if (cache) {
+      cache.state.dataUpdatedAt = 0
+      cache.state.fetchStatus = fetchStatus
+    }
+  }

Then, for example:

-        queryClient.setQueryData(key, { amount: 10 })
-        const cache = queryClient.getQueryCache().find({ queryKey: key })
-        if (cache) {
-          cache.state.dataUpdatedAt = 0
-          cache.state.fetchStatus = 'idle'
-        }
+        markHydrated(queryClient, key, { amount: 10 }, 'idle')
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between b6516bd and 29eb336.

📒 Files selected for processing (3)
  • packages/query-core/src/__tests__/queryObserver.test.tsx (1 hunks)
  • packages/query-core/src/queryObserver.ts (1 hunks)
  • packages/react-query/src/useBaseQuery.ts (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (3)
packages/query-core/src/queryObserver.ts (1)
packages/query-core/src/types.ts (1)
  • QueryObserverResult (899-904)
packages/react-query/src/useBaseQuery.ts (1)
packages/query-core/src/queriesObserver.ts (1)
  • observer (254-260)
packages/query-core/src/__tests__/queryObserver.test.tsx (2)
packages/query-core/src/queriesObserver.ts (1)
  • observer (254-260)
packages/query-core/src/queryObserver.ts (1)
  • QueryObserver (39-765)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Preview
  • GitHub Check: Test
🔇 Additional comments (3)
packages/react-query/src/useBaseQuery.ts (1)

112-113: Switching server snapshot to getServerResult fixes hydration mismatch — LGTM

This is the right hook for SSR to align the server snapshot with server-rendered HTML under hydration. No behavior changes for client snapshots.

packages/query-core/src/queryObserver.ts (1)

267-294: Well-scoped server snapshot that models hydrated data as pending — LGTM

Non-mutating, minimal, and conditionally applied only under the hydrated-success state (dataUpdatedAt === 0). Keeps promise intact and mirrors fetchStatus for loading flags. This aligns with useSyncExternalStore’s SSR needs.

packages/query-core/src/__tests__/queryObserver.test.tsx (1)

1498-1671: Solid coverage of hydration cases and server/client snapshot divergence — LGTM

The tests clearly demonstrate the mismatch and validate the pending server snapshot across idle/fetching/error paths, plus divergence from client snapshots.

Copy link
Collaborator

@TkDodo TkDodo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for looking into this 🙌

Comment on lines 276 to 286
status: 'pending',
isPending: true,
isSuccess: false,
isError: false,
isLoading: currentResult.fetchStatus === 'fetching',
isInitialLoading: currentResult.fetchStatus === 'fetching',
data: undefined,
error: null,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how did you come up with this list of fields to change? For example, why aren’t fields like isFetched or isStale being re-set?

I’m wondering how we can make sure to keep this list in-sync if we add new properties to QueryObserverResult...

Copy link
Contributor Author

@joseph0926 joseph0926 Aug 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your feedback!

In conclusion, the criteria for selecting the specified fields was “only change fields that directly affect SSR hydration inconsistencies.”

I believe that intentionally maintaining the currentResult value for the remaining fields will be beneficial during subsequent maintenance processes.

However, based on coderabbitai's feedback, I realized that I had overlooked isRefetching, so I added it.
Since the status is fixed as pending, I added it to explicitly state that refetching is not possible.

cb50d25

currentResult.status === 'success' &&
this.#currentQuery.state.dataUpdatedAt === 0
) {
const pendingResult: QueryObserverResult<TData, TError> = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be of type QueryObserverPendingResult to make sure we’re at least always in-sync with what this type guarantees ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pointing out the type > QueryObserverPendingResult, which I had overlooked.
I agree that applying this will improve type stability.
However, since currentResult must be of type QueryObserverResult, I handled it as a union.

ab2ddad

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/query-core/src/queryObserver.ts (1)

277-288: Avoid brittle “whitelist” of overridden fields; centralize pending server result construction.

Manually overriding a subset of properties is easy to get out of sync with QueryObserverResult as new fields are added (e.g., isFetched, isRefetching was already missed). Consider extracting a small helper to construct a coherent pending server snapshot from currentResult and this.#currentQuery.state.fetchStatus, which sets all invariants in one place and is typed as QueryObserverPendingResult.

I can draft a private toServerPendingResult(...) helper to keep this maintainable if helpful.

🧹 Nitpick comments (1)
packages/query-core/src/queryObserver.ts (1)

268-271: Simplify return type: QueryObserverPendingResult is already part of QueryObserverResult.

QueryObserverPendingResult is a constituent of QueryObserverResult, so the explicit union is redundant. Keeping the return type as just QueryObserverResult<TData, TError> reduces noise.

-  getServerResult():
-    | QueryObserverPendingResult<TData, TError>
-    | QueryObserverResult<TData, TError> {
+  getServerResult(): QueryObserverResult<TData, TError> {
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 29eb336 and ab2ddad.

📒 Files selected for processing (1)
  • packages/query-core/src/queryObserver.ts (2 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
packages/query-core/src/queryObserver.ts (1)
packages/query-core/src/types.ts (2)
  • QueryObserverPendingResult (797-810)
  • QueryObserverResult (899-904)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Test
  • GitHub Check: Preview

Comment on lines 268 to 291
getServerResult():
| QueryObserverPendingResult<TData, TError>
| QueryObserverResult<TData, TError> {
const currentResult = this.#currentResult

const isHydratedData =
currentResult.status === 'success' &&
this.#currentQuery.state.dataUpdatedAt === 0

if (isHydratedData) {
return {
...currentResult,
status: 'pending' as const,
isPending: true,
isSuccess: false,
data: undefined,
isLoading: currentResult.fetchStatus === 'fetching',
isInitialLoading: currentResult.fetchStatus === 'fetching',
isPlaceholderData: false,
}
}

return currentResult
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Fix inconsistent flags for pending server snapshot (isRefetching, error flags, fetched flags).

When transforming a hydrated success result into a pending server snapshot, we spread currentResult and override a few fields. This leaves inconsistencies:

  • If fetchStatus === 'fetching', currentResult.isRefetching was true (because success + fetching), but a pending result must have isRefetching: false (as computed in createResult: isRefetching = isFetching && !isPending).
  • Pending semantics also guarantee isError: false, error: null, isLoadingError: false, isRefetchError: false, and it’s safer to mark isFetched/isFetchedAfterMount as false to avoid mixed semantics leaking from the success snapshot.

Recommend explicitly overriding these to match QueryObserverPendingResult invariants and make the branch type-safe.

Apply this diff inside getServerResult:

   if (isHydratedData) {
-    return {
-      ...currentResult,
-      status: 'pending' as const,
-      isPending: true,
-      isSuccess: false,
-      data: undefined,
-      isLoading: currentResult.fetchStatus === 'fetching',
-      isInitialLoading: currentResult.fetchStatus === 'fetching',
-      isPlaceholderData: false,
-    }
+    return {
+      ...currentResult,
+      status: 'pending' as const,
+      isPending: true,
+      isSuccess: false,
+      // pending invariants
+      isError: false,
+      error: null,
+      isLoadingError: false,
+      isRefetchError: false,
+      // data is not available for pending
+      data: undefined,
+      // compute loading flags from fetchStatus
+      isLoading: currentResult.fetchStatus === 'fetching',
+      isInitialLoading: currentResult.fetchStatus === 'fetching',
+      // pending cannot be "refetching"
+      isRefetching: false,
+      // do not report placeholder data
+      isPlaceholderData: false,
+      // avoid leaking fetched flags from the success snapshot
+      isFetched: false,
+      isFetchedAfterMount: false,
+    } as QueryObserverPendingResult<TData, TError>
   }
🤖 Prompt for AI Agents
In packages/query-core/src/queryObserver.ts around lines 268 to 291, when
converting a hydrated success result into a pending server snapshot we currently
spread currentResult but leave several flags inconsistent; explicitly override
the following fields to match QueryObserverPendingResult invariants: set
isRefetching to false, isError to false, error to null, isLoadingError to false,
isRefetchError to false, isFetched to false, and isFetchedAfterMount to false
(retain the existing overrides for
status/isPending/isSuccess/data/isLoading/isInitialLoading/isPlaceholderData).
Ensure these fields are explicitly assigned in the returned object so the
pending snapshot has consistent semantics and is type-safe.

💡 Verification agent

🧩 Analysis chain

Add test coverage for isRefetching and fetched flags in server snapshot.

To prevent regressions, assert in tests that:

  • getServerResult() for hydrated data sets isRefetching === false even if fetchStatus === 'fetching'.
  • isFetched === false and isFetchedAfterMount === false.

[offer_assistance]

If missing, I can provide a focused test case to assert these invariants.


🏁 Script executed:

#!/bin/bash
# Locate existing tests around getServerResult and hydration
rg -n -C3 "getServerResult|server snapshot|hydration" --type=ts --type=tsx

# Check if isRefetching/isFetched assertions exist
rg -n -C2 "isRefetching|isFetchedAfterMount|isFetched" packages/query-core/src/__tests__/queryObserver.test.tsx

Length of output: 218


🏁 Script executed:

#!/bin/bash
# Retry pattern search without relying on --type (tsx not recognized)
rg -n -C3 "getServerResult|server snapshot|hydration" -g '*.ts' -g '*.tsx'
rg -n -C2 "isRefetching|isFetchedAfterMount|isFetched" -g '*.ts' -g '*.tsx'

Length of output: 120039


Add missing assertions for isRefetching, isFetched, and isFetchedAfterMount in server snapshot tests

The existing hydration tests in packages/query-core/src/__tests__/queryObserver.test.tsx verify status, data, and fetchStatus, but don’t cover these derived flags. To prevent regressions, extend the tests under the Solution with getServerResult block:

• After the “should return pending state for hydrated data” test (around line 1530), add:

expect(serverResult.isFetched).toBe(false)
expect(serverResult.isFetchedAfterMount).toBe(false)
expect(serverResult.isRefetching).toBe(false)

• After the “should handle fetching state during hydration” test (around line 1575), also assert:

expect(serverResult.isFetched).toBe(false)
expect(serverResult.isFetchedAfterMount).toBe(false)
expect(serverResult.isRefetching).toBe(false)

This will ensure that hydrated snapshots never report fetched or refetching states on the server.

🤖 Prompt for AI Agents
In packages/query-core/src/queryObserver.ts around lines 268 to 291: the server
result produced for hydrated data is missing corresponding unit-test assertions;
update the tests in packages/query-core/src/__tests__/queryObserver.test.tsx
(after the "should return pending state for hydrated data" test near line ~1530
and after the "should handle fetching state during hydration" test near line
~1575) to add assertions that serverResult.isFetched is false,
serverResult.isFetchedAfterMount is false, and serverResult.isRefetching is
false so hydrated server snapshots never report fetched or refetching states.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
packages/query-core/src/queryObserver.ts (2)

277-293: Pending server snapshot leaks fetched flags; align with pending invariants.

When masking a hydrated success as pending, isFetched/isFetchedAfterMount may still reflect the success snapshot due to the spread. Explicitly reset them to false to avoid mixed semantics. Optionally, cast the object to QueryObserverPendingResult for stronger type guarantees.

Apply this diff within the pending branch:

     if (isHydratedData) {
       return {
         ...currentResult,
         status: 'pending' as const,
         isPending: true,
         isSuccess: false,
         isError: false,
         error: null,
         isLoadingError: false,
         isRefetchError: false,
         data: undefined,
         isRefetching: false,
         isLoading: currentResult.fetchStatus === 'fetching',
         isInitialLoading: currentResult.fetchStatus === 'fetching',
         isPlaceholderData: false,
+        // avoid leaking "fetched" semantics from the success snapshot
+        isFetched: false,
+        isFetchedAfterMount: false,
-      }
+      } as QueryObserverPendingResult<TData, TError>
     }

277-293: Add test assertions for fetched/refetch flags in server snapshot.

To prevent regressions, extend the SSR/hydration tests to assert:

  • serverResult.isFetched === false
  • serverResult.isFetchedAfterMount === false
  • serverResult.isRefetching === false

This ensures the pending server snapshot never exposes “fetched” or “refetching” semantics.

🧹 Nitpick comments (1)
packages/query-core/src/queryObserver.ts (1)

273-276: Sentinel usage confirmed, no unintended zero-timestamps detected

  • dataUpdatedAt === 0 only appears as the default initial value in tests and is never reset to 0 elsewhere in your runtime code.
  • The isHydratedData check in packages/query-core/src/queryObserver.ts:273–276 therefore correctly identifies only “fresh” hydration cases.
  • No other code paths or hydration routines assign dataUpdatedAt = 0 post-dehydration.

Nitpick (optional): if you prefer to avoid sprinkling this magic number, you could extract a small helper (e.g. isHydrated(result)) to centralize the sentinel logic.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between ab2ddad and 907def0.

📒 Files selected for processing (1)
  • packages/query-core/src/queryObserver.ts (2 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
packages/query-core/src/queryObserver.ts (1)
packages/query-core/src/types.ts (2)
  • QueryObserverPendingResult (797-810)
  • QueryObserverResult (899-904)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Test
  • GitHub Check: Preview
🔇 Additional comments (2)
packages/query-core/src/queryObserver.ts (2)

26-26: LGTM: type-only import for QueryObserverPendingResult.

Using import type keeps this out of runtime bundles and is consistent with surrounding imports.


268-271: Great addition: dedicated server snapshot API for useSyncExternalStore.

Providing a server-specific snapshot via getServerResult cleanly separates SSR vs. client snapshots and addresses the hydration mismatch root cause without mutating observer state.

Copy link

codecov bot commented Aug 17, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.29%. Comparing base (a1b1279) to head (3429d75).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #9572       +/-   ##
===========================================
+ Coverage   45.15%   59.29%   +14.14%     
===========================================
  Files         208      137       -71     
  Lines        8323     5570     -2753     
  Branches     1886     1502      -384     
===========================================
- Hits         3758     3303      -455     
+ Misses       4118     1963     -2155     
+ Partials      447      304      -143     
Components Coverage Δ
@tanstack/angular-query-devtools-experimental ∅ <ø> (∅)
@tanstack/angular-query-experimental 87.00% <ø> (ø)
@tanstack/eslint-plugin-query ∅ <ø> (∅)
@tanstack/query-async-storage-persister 43.85% <ø> (ø)
@tanstack/query-broadcast-client-experimental 24.39% <ø> (ø)
@tanstack/query-codemods ∅ <ø> (∅)
@tanstack/query-core 97.42% <100.00%> (+0.01%) ⬆️
@tanstack/query-devtools 3.48% <ø> (ø)
@tanstack/query-persist-client-core 79.47% <ø> (ø)
@tanstack/query-sync-storage-persister 84.61% <ø> (ø)
@tanstack/query-test-utils ∅ <ø> (∅)
@tanstack/react-query 95.95% <100.00%> (ø)
@tanstack/react-query-devtools 10.00% <ø> (ø)
@tanstack/react-query-next-experimental ∅ <ø> (∅)
@tanstack/react-query-persist-client 100.00% <ø> (ø)
@tanstack/solid-query 78.13% <ø> (ø)
@tanstack/solid-query-devtools ∅ <ø> (∅)
@tanstack/solid-query-persist-client 100.00% <ø> (ø)
@tanstack/svelte-query 87.58% <ø> (ø)
@tanstack/svelte-query-devtools ∅ <ø> (∅)
@tanstack/svelte-query-persist-client 100.00% <ø> (ø)
@tanstack/vue-query 71.10% <ø> (ø)
@tanstack/vue-query-devtools ∅ <ø> (∅)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@TkDodo
Copy link
Collaborator

TkDodo commented Aug 18, 2025

I’m not an expert here so I’ll wait for @Ephem to jump in but the way I see it, the getServerSnapshot should always return the value that we get from the server render; In that sense, we should probably “only” need to (conceptually) put that on a separate context and then read it from there

@TkDodo TkDodo requested a review from Ephem August 18, 2025 17:47
@joseph0926
Copy link
Contributor Author

joseph0926 commented Aug 18, 2025

I’m not an expert here so I’ll wait for @Ephem to jump in but the way I see it, the getServerSnapshot should always return the value that we get from the server render; In that sense, we should probably “only” need to (conceptually) put that on a separate context and then read it from there

@TkDodo

Hmm, I understand what you're saying.
The React documentation states, “The server snapshot must be the same between the client and the server, and is usually serialized and passed from the server to the client.”

My PR currently checks the status of currentResult at runtime and changes it dynamically, so there is a possibility of inconsistency.
Additionally, from a maintenance perspective, while currently checking currentResult.status === ‘success’ may suffice, it's unclear how things will change in the future, so there's the issue of having to modify the if statement each time.

The Context approach you suggested preserves the value rather than transforming it, which aligns more closely with React's philosophy.
Furthermore, since Context “preserves” a fixed value and only “returns” it based on the environment, from a maintenance perspective, it doesn't matter if the state changes or not, so it's better.

I'm also curious about @Ephem final opinion.

For now, I see two paths forward

  1. Keep the current implementation as it solves the immediate problem
  2. Refactor to use Context approach for better alignment with React's philosophy

For now, I will wait for @Ephem response and refactor this PR using the Context method.


+++
I tried to implement it using the Context method, but I got stuck on the core part.
The Context method ultimately solves the problem by “preserving” and ‘transmitting’ rather than “converting,”
but the problem is that the “original server state” to be preserved does not exist in the first place.

  1. After prefetching on the server → the cache is already in a success state
  2. During component rendering → the success state is read as-is
  3. The fact that it was “rendered as pending” is a fiction

In other words, the “actual pending state created by the server” that we want to put into Context has never actually existed, has it?

The server only knows the “success” state from start to finish, and the “pending” state we desire is merely our wish for how it should appear. Therefore, the Context approach ultimately

// Not “preservation” but still “creation/transformation”
const serverSnapshot = {
  status: ‘pending’,  // artificially created
  data: undefined,    // hiding the actual data
}

This is an artificially created state being placed into the Context, not preserving the actual server state.

@joseph0926
Copy link
Contributor Author

25fc95e

I added comprehensive test cases to verify the issues you raised and obtained the following results.

Test 1
This test verified that “the masking logic only handles the ‘success’ state and omits other potential success-like states.” If new state types such as ‘idle’ are added in future versions, the current implementation will pass without change. In other words, we confirmed that problems such as “the state is ‘idle’ but data exists” could occur.

Test 2
The current PR implementation correctly handles field consistency. When the client is isRefetching: true and isPending: false, the server view correctly masks this as isRefetching: false and isPending: true to maintain SSR/CSR consistency.

Test 3
Although not directly related to the above feedback, we discovered an issue during testing.
When refetch() is called on hydrated data, the observer's snapshot mechanism can cause currentResult.fetchStatus to become an outdated value. To resolve this, we used queryState.fetchStatus instead.

// Before
isLoading: currentResult.fetchStatus === ‘fetching’,

// After  
isLoading: queryState.fetchStatus === ‘fetching’,

This change ensures that the real-time fetch status is reflected even during concurrent operations.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
packages/query-core/src/__tests__/queryObserver.test.tsx (4)

1503-1508: Avoid mutating query.state directly; use query.setState to keep invariants and notifications intact

These tests directly mutate cache.state.{dataUpdatedAt,fetchStatus}. This bypasses Query’s state machinery and can make tests brittle against internal changes (e.g., observers not being notified, internal derived fields not recomputed). Prefer cache.setState(...).

Example fix for Lines 1503–1508:

-        if (cache) {
-          cache.state.dataUpdatedAt = 0
-          cache.state.fetchStatus = 'idle'
-        }
+        if (cache) {
+          cache.setState({ dataUpdatedAt: 0, fetchStatus: 'idle' })
+        }

Similarly adjust Lines 1562–1567:

-        if (cache) {
-          cache.state.dataUpdatedAt = 0
-          cache.state.fetchStatus = 'fetching'
-        }
+        if (cache) {
+          cache.setState({ dataUpdatedAt: 0, fetchStatus: 'fetching' })
+        }

Lines 1586–1590:

-        if (cache) {
-          cache.state.dataUpdatedAt = Date.now()
-        }
+        if (cache) {
+          cache.setState({ dataUpdatedAt: Date.now() })
+        }

Lines 1712–1717:

-        if (cache) {
-          cache.state.dataUpdatedAt = 0
-          cache.state.fetchStatus = 'fetching'
-        }
+        if (cache) {
+          cache.setState({ dataUpdatedAt: 0, fetchStatus: 'fetching' })
+        }

Lines 1740–1745:

-        if (cache) {
-          cache.state.dataUpdatedAt = 0
-          cache.state.fetchStatus = 'idle'
-        }
+        if (cache) {
+          cache.setState({ dataUpdatedAt: 0, fetchStatus: 'idle' })
+        }

Also applies to: 1562-1567, 1586-1590, 1712-1717, 1740-1745


1501-1501: Clarify test title to reflect what is asserted

This test doesn’t actually demonstrate a hydration “mismatch”; it asserts the client result is “success”. Consider renaming to make intent explicit:

-      test('should demonstrate hydration mismatch issue (before fix)', () => {
+      test('client snapshot with hydrated data is success (illustrates mismatch risk pre-fix)', () => {

1680-1685: Reconsider the “future-proof status” test using status: 'idle' as any

Injecting an out-of-contract status via as any couples the test to speculation about future versions and may confuse readers because it asserts that data can exist with an "idle" status. If the goal is to document that getServerResult only masks when status === 'success', make that explicit instead, or mark this as a documentation-style test with a clear comment.

  • Option A: Drop the test to avoid validating undefined behavior.
  • Option B: Keep it, but add a prominent comment explaining it asserts current, intentional masking criteria.

1656-1669: Optional: Add an end-to-end React test in packages/react-query to exercise useSyncExternalStore snapshot parity

This unit test checks the two snapshots via getCurrentResult and getServerResult, which is good. For higher confidence, consider adding an integration test in packages/react-query that mounts a tiny component using useBaseQuery and verifies that the server snapshot (from uSES getServerSnapshot) matches the HTML and that hydration does not mismatch when client data resolves.

Happy to help draft that test if desired.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between cb50d25 and 25fc95e.

📒 Files selected for processing (2)
  • packages/query-core/src/__tests__/queryObserver.test.tsx (1 hunks)
  • packages/query-core/src/queryObserver.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/query-core/src/queryObserver.ts
🧰 Additional context used
🧬 Code Graph Analysis (1)
packages/query-core/src/__tests__/queryObserver.test.tsx (3)
packages/query-core/src/queriesObserver.ts (1)
  • observer (254-260)
packages/query-core/src/queryObserver.ts (1)
  • QueryObserver (40-770)
packages/query-core/src/utils.ts (1)
  • sleep (355-359)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Test
  • GitHub Check: Preview

Comment on lines +1589 to +1590
cache.state.dataUpdatedAt = Date.now()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Use a non-zero constant (or set system time) to avoid flakiness with fake timers

Under fake timers, Date.now() may be 0 unless you set the system time. That would unintentionally mark this as “hydrated” (dataUpdatedAt === 0), causing getServerResult to mask the result and fail the “non-hydrated” expectations.

Minimal, deterministic fix:

-          cache.state.dataUpdatedAt = Date.now()
+          cache.setState({ dataUpdatedAt: 1 })

Alternatively, set the mocked clock first:

vi.setSystemTime(1)
cache.setState({ dataUpdatedAt: Date.now() })

🏁 Script executed:

#!/bin/bash
# Locate usages of dataUpdatedAt to see surrounding test logic
rg -n "dataUpdatedAt" -C3 packages/query-core/src/__tests__/queryObserver.test.tsx

# Find direct Date.now() calls in the test file
rg -n "Date.now" -C3 packages/query-core/src/__tests__/queryObserver.test.tsx

# Check for any mocked clock setup or system time manipulation
rg -n "setSystemTime" -C3 packages/query-core/src/__tests__/queryObserver.test.tsx

# Search for fake-timer setup (vi.useFakeTimers or jest.useFakeTimers)
rg -nP "useFakeTimers|fakeTimers" -C3 packages/query-core/src/__tests__/queryObserver.test.tsx

Length of output: 3741


Use a non-zero constant for dataUpdatedAt to avoid flakiness under fake timers

Under vi.useFakeTimers(), Date.now() returns 0 unless you explicitly advance the clock—making your “fresh” timestamp indistinguishable from the unhydrated state. Update the assignment in packages/query-core/src/__tests__/queryObserver.test.tsx to use a non-zero literal (or advance the fake clock first):

• Minimal, consistent with other direct state mutations:

- cache.state.dataUpdatedAt = Date.now()
+ cache.state.dataUpdatedAt = 1

• Alternative (advance the fake clock, then read):

vi.setSystemTime(1)
cache.state.dataUpdatedAt = Date.now()
🤖 Prompt for AI Agents
In packages/query-core/src/__tests__/queryObserver.test.tsx around lines
1589-1590, the test sets cache.state.dataUpdatedAt = Date.now() while using
vi.useFakeTimers(), which makes Date.now() return 0 and causes flakiness; change
the assignment to use a non-zero literal (e.g. set dataUpdatedAt to 1) or
advance the fake timers first by calling vi.setSystemTime(1) and then assign
Date.now() so the "fresh" timestamp is non-zero and consistent under fake
timers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Hydration error using loading state of useQuery when prefetching useSyncExternalStore should have a correct/frozen getServerSnapshot
2 participants